﻿/* 
 * Copyright (c) Intel Corporation
 * All rights reserved.
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * -- Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * -- Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 * -- Neither the name of the Intel Corporation nor the names of its
 *    contributors may be used to endorse or promote products derived from
 *    this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
 * PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE INTEL OR ITS
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Net;
using System.Text;
using System.Web;
using DotNetOpenAuth;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.OAuth;
using DotNetOpenAuth.OAuth.ChannelElements;
using DotNetOpenAuth.OAuth.Messages;
using DotNetOpenAuth.OpenId;
using DotNetOpenAuth.OpenId.ChannelElements;
using DotNetOpenAuth.OpenId.RelyingParty;
using ExtensionLoader;
using ExtensionLoader.Config;
using HttpServer;
using OpenMetaverse;
using OpenMetaverse.Http;
using OpenMetaverse.StructuredData;

namespace InventoryServer.Extensions
{
    public class OAuthRequest
    {
        public Uri Identity;
        public UserAuthorizationRequest Request;
        public string[] CapabilityNames;

        public OAuthRequest(Uri identity, UserAuthorizationRequest request, string[] capabilityNames)
        {
            Identity = identity;
            Request = request;
            CapabilityNames = capabilityNames;
        }
    }

    public class OAuthFrontend : IExtension<InventoryServer>
    {
        const string PERMISSION_GRANT_PAGE = InventoryServer.WEB_CONTENT_DIR + "oauthconfirm.tpl";
        const string FACEBOOK_LOGIN_PAGE = InventoryServer.WEB_CONTENT_DIR + "facebooklogin.tpl";

        InventoryServer server;
        OpenIdRelyingParty relyingParty = new OpenIdRelyingParty(new StandardRelyingPartyApplicationStore());
        DoubleDictionary<Uri, string, OAuthRequest> currentRequests = new DoubleDictionary<Uri, string, OAuthRequest>();
        InMemoryTokenManager tokenManager;
        DotNetOpenAuth.OAuth.ServiceProvider serviceProvider;
        string fbAppKey;
        string fbAppSecret;

        public OAuthFrontend()
        {
        }

        public bool Start(InventoryServer server)
        {
            this.server = server;

            #region Config Loading

            try
            {
                IConfig extensionConfig = server.ConfigFile.Configs["Facebook"];
                fbAppKey = extensionConfig.GetString("ApiKey", String.Empty);
                fbAppSecret = extensionConfig.GetString("AppSecret", String.Empty);
            }
            catch (Exception)
            {
                Logger.Info("[OAuthFrontend] Failed to load [Facebook] section from " + InventoryServer.CONFIG_FILE + ", disabling Facebook support");
            }

            #endregion Config Loading

            tokenManager = new InMemoryTokenManager();
            serviceProvider = new DotNetOpenAuth.OAuth.ServiceProvider(OpenAuthHelper.CreateServiceProviderDescription(server.HttpUri), tokenManager);

            server.HttpServer.AddHandler("POST", null, @"^/oauth/get_request_token", GetRequestTokenHandler);
            server.HttpServer.AddHandler("GET", null, @"^/oauth/authorize_token", AuthorizeTokenHandler);
            server.HttpServer.AddHandler("POST", null, @"^/oauth/get_access_token", GetAccessTokenHandler);
            
            server.HttpServer.AddHandler("GET", null, @"^/oauth/openid_callback", OpenIDCallbackHandler);
            server.HttpServer.AddHandler("GET", null, @"^/facebook/login", FacebookLoginHandler);
            
            server.HttpServer.AddHandler("POST", "application/x-www-form-urlencoded", @"^/oauth/user_authorize", AuthorizeTokenFormHandler);

            return true;
        }

        public void Stop()
        {
        }

        void GetRequestTokenHandler(IHttpClientContext client, IHttpRequest request, IHttpResponse response)
        {
            UnauthorizedTokenRequest tokenRequest = serviceProvider.ReadTokenRequest(OpenAuthHelper.GetRequestInfo(request));

            if (tokenRequest != null)
            {
                // TODO: If we wanted to support whitelisting/blacklisting worlds, we could do so here with
                // tokenRequest.ConsumerKey

                UnauthorizedTokenResponse tokenResponse = serviceProvider.PrepareUnauthorizedTokenMessage(tokenRequest);
                OpenAuthHelper.OpenAuthResponseToHttp(response, serviceProvider.Channel.PrepareResponse(tokenResponse));
            }
            else
            {
                response.Status = HttpStatusCode.BadRequest;
            }
        }

        void AuthorizeTokenHandler(IHttpClientContext client, IHttpRequest request, IHttpResponse response)
        {
            HttpRequestInfo requestInfo = OpenAuthHelper.GetRequestInfo(request);
            string identityStr;
            Uri identity;
            string authMethod;

            try
            {
                UserAuthorizationRequest oauthRequest = serviceProvider.ReadAuthorizationRequest(requestInfo);

                if (oauthRequest != null && oauthRequest.ExtraData != null &&
                oauthRequest.ExtraData.TryGetValue("cb_identity", out identityStr) && Uri.TryCreate(identityStr, UriKind.Absolute, out identity) &&
                oauthRequest.ExtraData.TryGetValue("cb_auth_method", out authMethod))
                {
                    // FIXME: Right now we whitelist identities from the local domain and blacklist everyone else.
                    // This is a policy decision that should be expressed through a configuration file, not baked
                    // into the code.
                    if (identity.DnsSafeHost.Equals(server.HttpUri.DnsSafeHost))
                    {
                        IServiceProviderRequestToken requestToken = tokenManager.GetRequestToken(oauthRequest.RequestToken);
                        if (requestToken.Callback != null)
                            oauthRequest.Callback = requestToken.Callback;

                        if (oauthRequest.Callback != null)
                        {
                            string capNameList;
                            if (oauthRequest.ExtraData.TryGetValue("cb_capabilities", out capNameList))
                            {
                                OAuthRequest thisRequest;
                                string[] capNames = capNameList.Split(',');

                                // Remove any previous authorization requests for this identity
                                if (currentRequests.TryGetValue(identity, out thisRequest) && thisRequest.Request.RequestToken != oauthRequest.RequestToken)
                                {
                                    Logger.Warn("[OAuthFrontend] Replacing incomplete OAuth request from identity " + identity);
                                    currentRequests.Remove(identity, thisRequest.Request.RequestToken);
                                }

                                thisRequest = new OAuthRequest(identity, oauthRequest, capNames);
                                currentRequests.Add(identity, oauthRequest.RequestToken, thisRequest);

                                switch (authMethod)
                                {
                                    case CableBeachMessages.CableBeachAuthMethods.OPENID:
                                        {
                                            #region OpenID Authentication

                                            string baseURL = String.Format("{0}://{1}", server.HttpUri.Scheme, server.HttpUri.Authority);
                                            Realm realm = new Realm(baseURL);

                                            try
                                            {
                                                Identifier identifier;
                                                Identifier.TryParse(identityStr, out identifier);

                                                IAuthenticationRequest authRequest = relyingParty.CreateRequest(
                                                    identifier, realm, new Uri(server.HttpUri, "/oauth/openid_callback"));
                                                OpenAuthHelper.OpenAuthResponseToHttp(response, authRequest.RedirectingResponse);
                                            }
                                            catch (Exception ex)
                                            {
                                                Logger.Error("[OAuthFrontend] OpenID authentication failed: " + ex.Message);
                                                response.AddToBody("OpenID authentication failed: " + ex.Message);
                                            }

                                            #endregion OpenID Authentication
                                        }
                                        break;
                                    case CableBeachMessages.CableBeachAuthMethods.FACEBOOK:
                                        {
                                            FacebookLoginHandler(client, request, response);
                                        }
                                        break;
                                    default:
                                        {
                                            Logger.Error("[OAuthFrontend] Unsupported authentication method requested: " + authMethod);
                                            response.AddToBody("Unsupported authentication method requested: " + authMethod);
                                        }
                                        break;
                                }
                            }
                            else
                            {
                                // No capabilities were requested
                                Logger.Error("[OAuthFrontend] Got an OAuth request from " + identity + " with no capabilities being requested");
                                response.Status = HttpStatusCode.BadRequest;
                                response.AddToBody("Unknown capabilities");
                            }
                        }
                        else
                        {
                            // No callback was given
                            Logger.Error("[OAuthFrontend] Got an OAuth request from " + identity + " with no callback given");
                            response.Status = HttpStatusCode.BadRequest;
                            response.AddToBody("No callback given");
                        }
                    }
                    else
                    {
                        // User identity is not authorized to access this service
                        Logger.Error("[OAuthFrontend] Unauthorized identity: " + identity);
                        response.AddToBody("Unauthorized identity: " + identity);
                    }
                }
                else
                {
                    // No identity parameter was passed
                    Logger.Error("[OAuthFrontend] Missing cb_identity or cb_auth_method in AuthorizeTokenHandler");
                    response.Status = HttpStatusCode.BadRequest;
                    response.AddToBody("Missing cb_identity or cb_auth_method");
                }
            }
            catch (Exception ex)
            {
                response.Status = HttpStatusCode.BadRequest;
                response.AddToBody("Failed to handle OAuth request: " + ex.Message);
            }
        }

        void OpenIDCallbackHandler(IHttpClientContext client, IHttpRequest request, IHttpResponse response)
        {
            HttpRequestInfo requestInfo = OpenAuthHelper.GetRequestInfo(request);

            try
            {
                IAuthenticationResponse openidResponse = relyingParty.GetResponse(requestInfo);

                if (openidResponse != null && openidResponse.Status == AuthenticationStatus.Authenticated)
                {
                    Uri identity = new Uri(openidResponse.ClaimedIdentifier.ToString());

                    Logger.Info("[OAuthFrontend] OpenID authentication succeeded for " + identity);

                    OAuthRequest oauthRequest;
                    if (currentRequests.TryGetValue(identity, out oauthRequest))
                    {
                        // OpenID authentication succeeded, ask the user if they want to grant capabilities to the requesting world
                        SendPermissionGrantTemplate(response, oauthRequest);
                    }
                    else
                    {
                        // OpenID auth succeeded for an untracked request? Strange...
                        Logger.Error("[OAuthFrontend] Unknown identity in OpenIDCallbackHandler: " + identity);
                        response.Status = HttpStatusCode.BadRequest;
                        response.AddToBody("Unknown identity: " + identity);
                    }
                }
                else
                {
                    // OpenID authentication was cancelled or had some other failure
                    Logger.Error("[OAuthFrontend] OpenID authentication failed");
                    response.Redirect(server.HttpUri);
                }
            }
            catch (Exception ex)
            {
                Logger.Error("[OAuthFrontend] Error in OpenID callback: " + ex.Message);
                response.Redirect(server.HttpUri);
            }
        }

        void FacebookLoginHandler(IHttpClientContext client, IHttpRequest request, IHttpResponse response)
        {
            Dictionary<string, string> userInfo = null;
            string profileUrlStr;
            Uri identity;

            try
            {
                FacebookConnect fbConnect = new FacebookConnect(fbAppKey, fbAppSecret);
                FacebookConnectSession fbSession = fbConnect.GetSession(ConvertCookies(request.Cookies));
                userInfo = FacebookConnect.GetUserInfo(fbConnect.ApiKey, fbConnect.AppSecret, fbSession.SessionKey, fbSession.UserID);
            }
            catch (Exception ex)
            {
                Logger.Info("[OAuthFrontend] No valid Facebook authentication cookies found: " + ex.Message);
                SendFacebookLoginTemplate(response, fbAppKey);
                return;
            }

            if (userInfo != null &&
                userInfo.TryGetValue("profile_url", out profileUrlStr) &&
                Uri.TryCreate(profileUrlStr, UriKind.Absolute, out identity))
            {
                Logger.Info("[OAuthFrontend] Facebook authentication succeeded for " + identity);

                OAuthRequest oauthRequest;
                if (currentRequests.TryGetValue(identity, out oauthRequest))
                {
                    // Facebook authentication succeeded, ask the user if they want to grant capabilities to the requesting world
                    SendPermissionGrantTemplate(response, oauthRequest);
                }
                else
                {
                    // Facebook auth succeeded for an untracked request? Strange...
                    Logger.Error("[OAuthFrontend] Facebook authentication succeeded for an untracked identity: " + identity);
                    SendFacebookLoginTemplate(response, fbAppKey);
                }
            }
            else
            {
                // Facebook authentication did not succeed, present the login page
                Logger.Error("[OAuthFrontend] Facebook authentication failed");
                SendFacebookLoginTemplate(response, fbAppKey);
            }
        }

        void AuthorizeTokenFormHandler(IHttpClientContext client, IHttpRequest request, IHttpResponse response)
        {
            NameValueCollection query;

            try
            {
                byte[] requestData = request.GetBody();
                string queryString = HttpUtility.UrlDecode(requestData, Encoding.UTF8);
                query = System.Web.HttpUtility.ParseQueryString(queryString);
            }
            catch (Exception)
            {
                response.Status = HttpStatusCode.BadRequest;
                return;
            }

            Uri callback;
            if (Uri.TryCreate(GetQueryValue(query, "callback"), UriKind.Absolute, out callback))
            {
                string confirm = GetQueryValue(query, "confirm");
                string requestToken = GetQueryValue(query, "request_token").Replace(' ', '+');

                OAuthRequest oauthRequest;
                if (!String.IsNullOrEmpty(confirm) && !String.IsNullOrEmpty(requestToken))
                {
                    if (currentRequests.TryGetValue(requestToken, out oauthRequest))
                    {
                        // Mark the request token as authorized
                        tokenManager.AuthorizeRequestToken(requestToken);

                        // Create an authorization response (including a verification code)
                        UserAuthorizationResponse oauthResponse = serviceProvider.PrepareAuthorizationResponse(oauthRequest.Request);

                        // Update the verification code for this request to the newly created verification code
                        try { tokenManager.GetRequestToken(requestToken).VerificationCode = oauthResponse.VerificationCode; }
                        catch (KeyNotFoundException)
                        {
                            Logger.Error("[OAuthFrontend] Did not recognize request token " + requestToken +
                                ", failed to update verification code");
                        }

                        Logger.Info("[OAuthFrontend] OAuth confirmation accepted, redirecting to " + callback);
                        OpenAuthHelper.OpenAuthResponseToHttp(response, serviceProvider.Channel.PrepareResponse(oauthResponse));
                    }
                    else
                    {
                        // TODO: Should we be redirecting to the callback with a failure parameter set instead?
                        Logger.Error("[OAuthFrontend] Could not find an open request matching request token \"" + requestToken + "\"");
                        response.Redirect(server.HttpUri);
                    }
                }
                else
                {
                    // TODO: Should we be redirecting to the callback with a failure parameter set instead?
                    Logger.Warn("[OAuthFrontend] OAuth confirmation (redirecting to " + callback + ") was denied");
                    response.Redirect(server.HttpUri);
                }
            }
            else
            {
                Logger.Warn("[OAuthFrontend] Received a POST to the OAuth confirmation form with no callback");
                response.Status = HttpStatusCode.BadRequest;
                response.AddToBody("No callback specified");
            }
        }

        void GetAccessTokenHandler(IHttpClientContext client, IHttpRequest request, IHttpResponse response)
        {
            AuthorizedTokenRequest tokenRequest = null;
            OAuthRequest oauthRequest;

            try { tokenRequest = serviceProvider.ReadAccessTokenRequest(OpenAuthHelper.GetRequestInfo(request)); }
            catch (Exception ex)
            {
                Logger.Error("[OAuthFrontend] Failed to parse access token request message: " + ex.Message);
            }

            if (tokenRequest != null && (currentRequests.TryGetValue(tokenRequest.RequestToken, out oauthRequest)))
            {
                UUID agentID = server.FilesystemProvider.IdentityToUUID(oauthRequest.Identity);

                // Remove this request from the dictionary of currently tracked requests
                currentRequests.Remove(oauthRequest.Identity, oauthRequest.Request.RequestToken);

                AuthorizedTokenResponse tokenResponse = serviceProvider.PrepareAccessTokenMessage(tokenRequest);
                
                // Get the list of requested capabilities that was sent earlier
                Dictionary<Uri, Uri> capabilities = new Dictionary<Uri, Uri>(oauthRequest.CapabilityNames.Length);
                for (int i = 0; i < oauthRequest.CapabilityNames.Length; i++)
                {
                    Uri serviceIdentifier;
                    if (Uri.TryCreate(oauthRequest.CapabilityNames[i], UriKind.Absolute, out serviceIdentifier))
                        capabilities[serviceIdentifier] = null;
                    else
                        Logger.Warn("[OAuthFrontend] Unrecognized service identifier in capability request: " + oauthRequest.CapabilityNames[i]);
                }

                // Allow each registered service to attempt to fill in the capabilities request
                server.ServiceRegistrationProvider.CreateCapabilities(oauthRequest.Identity, ref capabilities);

                // Convert the list of capabilities into <string,string> tuples, leaving out requests with empty values
                Dictionary<string, string> capStrings = new Dictionary<string, string>(capabilities.Count);
                foreach (KeyValuePair<Uri, Uri> entry in capabilities)
                {
                    if (entry.Value != null)
                        capStrings[entry.Key.ToString()] = entry.Value.ToString();
                    else
                        Logger.Warn("[OAuthFrontend] Capability was not created for service identifier " + entry.Key);
                }

                // Put the created capabilities in the OAuth response and send the response
                tokenResponse.SetExtraData(capStrings);
                OpenAuthHelper.OpenAuthResponseToHttp(response, serviceProvider.Channel.PrepareResponse(tokenResponse));
            }
            else
            {
                Logger.Warn("[OAuthFrontend] get_access_token called with invalid or missing request token");
                response.Status = HttpStatusCode.BadRequest;
            }
        }

        void SendFacebookLoginTemplate(IHttpResponse response, string fbApiKey)
        {
            Dictionary<string, object> variables = new Dictionary<string, object>();
            variables["fb_api_key"] = fbApiKey;

            try
            {
                string output = server.HttpTemplates.Render(FACEBOOK_LOGIN_PAGE, variables);
                byte[] data = Encoding.UTF8.GetBytes(output);
                response.ContentLength = data.Length;
                response.ContentType = "text/html";
                response.Body.Write(data, 0, data.Length);
            }
            catch (Exception ex)
            {
                Logger.Error("[OAuthFrontend] Failed to render " + FACEBOOK_LOGIN_PAGE + " template: " + ex.Message);
            }
        }

        void SendPermissionGrantTemplate(IHttpResponse response, OAuthRequest oauthRequest)
        {
            Dictionary<string, object> variables = new Dictionary<string, object>();
            variables["identity"] = oauthRequest.Identity;
            variables["callback"] = oauthRequest.Request.Callback;
            variables["request_token"] = oauthRequest.Request.RequestToken;
            variables["consumer"] = oauthRequest.Request.Callback.Authority;
            variables["capabilities"] = oauthRequest.CapabilityNames;

            try
            {
                string output = server.HttpTemplates.Render(PERMISSION_GRANT_PAGE, variables);
                byte[] data = Encoding.UTF8.GetBytes(output);
                response.ContentLength = data.Length;
                response.ContentType = "text/html";
                response.Body.Write(data, 0, data.Length);
            }
            catch (Exception ex)
            {
                Logger.Error("[OAuthFrontend] Failed to render " + PERMISSION_GRANT_PAGE + " template: " + ex.Message);
            }
        }

        static string GetQueryValue(string query, string key)
        {
            try
            {
                NameValueCollection queryValues = HttpUtility.ParseQueryString(query);
                string[] values = queryValues.GetValues(key);
                if (values != null && values.Length > 0)
                    return values[0];
            }
            catch (Exception) { }

            return null;
        }

        static string GetQueryValue(NameValueCollection queryValues, string key)
        {
            string[] values = queryValues.GetValues(key);
            if (values != null && values.Length > 0)
                return values[0];
            return null;
        }

        static Uri RemoveQuery(Uri uri)
        {
            return new Uri(uri.Scheme + "://" + uri.Authority + uri.AbsolutePath);
        }

        static HttpCookieCollection ConvertCookies(RequestCookies cookies)
        {
            if (cookies == null)
                return null;

            HttpCookieCollection httpCookies = new HttpCookieCollection();
            foreach (RequestCookie cookie in cookies)
                httpCookies.Add(new HttpCookie(cookie.Name, cookie.Value));

            return httpCookies;
        }
    }
}
